解析:純 CSS 的圈圈叉叉


Posted by ArvinH on 2019-05-25

前言

在很久之前寫過一篇使用 CSS 繪圖的文章,但 CSS 能做的不僅止於此,利用 Pure CSS 製作遊戲的大有人在,像是 Pure CSS Games collection 裡面羅列的。

不過你知我知你隔壁戴眼鏡的也知,純 CSS 製作的圖畫或是遊戲,實作成本高、閱讀性不佳,也難以調教效能,幾乎無法應用到實際產品上。

可是很好玩啊!利用有限條件加上各種技巧想辦法完成預想中的效果,成就感是很大的,能夠有能力跟時間製作出這樣的作品,我覺得是很奢侈的幸福。

老實說我還沒有那個能力做到如此地步,但從觀察他人的作品就能學習到很多有趣的技巧!

今天就來分析一下在 codepen 上看到的這個純 CSS 圈圈叉叉是怎麼實做出來的:

See the Pen CSS Tic-Tac-Toe by Alvaro Montoro (@alvaromontoro) on CodePen.

觀察一下功能

Behavior

這個作品的完成度很高,實作出來的有以下功能:

  • 點選方格後,能夠留下不同 style 的 X 或是 O。
  • 滑鼠 hover 到方格上時,也還能輪流出現圈圈叉叉,讓你知道現在是輪到哪個玩家。
  • 遊戲結束時,能夠跳出選項重新玩。

對於 CSS 稍微敏銳一些,或是有看過類似作品的實作方式的讀者,應該蠻快可以猜到第一點能夠過什麼樣的技巧達成。但作者是如何讓圈圈與叉叉交替出現,我倒是無法很快想出來。

如果你也跟我一樣,別怕!

接下來我們從功能面來一步步拆解他所使用到的技巧,大家可以對照原作者程式碼看,會更好理解。

依靠純 CSS 如何在使用者點擊元素後,進而變更元素狀態呢?

透過 input[type=radio]:check 僞類別的結合,我們就能製造出點擊與非點擊的兩種狀態:

/* ... 省略 ... */
input[id*='-8-x']:checked ~ #board #tile-8 div::before {
  content: "X";
  background: #004974;
  color: #89dcf6;
}

/* ... 省略 ... */
input[id*='-8-o']:checked ~ #board #tile-8 div::before {
  content: "O";
  background: #a60011;
  color: #ffc7b5;
}

從原作程式碼中,可以看到有許多類似 pattern 的 selector,其中上面這兩種就是在處理 X 玩家與 O 玩家各自點擊了 board 上的一個空格後,該進行的 CSS 樣式改變。

input[id*='-8-x'],代表的是 選取所有 id attribute 中『包含』字串 '-8-x' 的 input 元素 。接著加上 :check 就能 filter 出被使用者選取的 input 元素。

而後面的 ~ #board #tile-8 div::before 則是表示,在符合上面條件的 input 元素所有符合 #board #tile-8 div 的 div 內,我們加上一個 ::before 僞元素,並且設定其 css 為 content: "X"

這樣的一段 CSS selector 被觸發後,就可以達到點擊後留下 X 標記的效果了:

X 玩家點擊空格

從中可以明顯看出,作者是利用 -o-x 這兩種 postfix 當作 X 玩家與 O 玩家的曲別,今天若是 id attribute 中含有 -0 的 input 元素被點選,就是 O 玩家點擊空格,得放入 O

此外,由於 input[type=radio] 元素,在瀏覽器中的固定樣式就是一個圓形選擇鈕,要替換成井字空格,並且又能觸發點擊的方式,就是結合 formlabel 元素,綁定對應的 input 按鈕:

<form id="tictactoe">
  <input type="radio" name="cell-0" id="cell-0-x" />
  <input type="radio" name="cell-0" id="cell-0-o" />
  <!-- ... 省略 ... -->
  <input type="radio" name="cell-8" id="cell-8-x" />
  <input type="radio" name="cell-8" id="cell-8-o" />
  <div id="board" class="center">
    <div class="tile" id="tile-0">
      <label for="cell-0-x"></label>
      <label for="cell-0-o"></label>
      <div></div>
    </div>
    <!-- ... 省略 ... -->
    <div class="tile" id="tile-8">
      <label for="cell-8-x"></label>
      <label for="cell-8-o"></label>
      <div></div>
    </div>
  </div>
  <!-- ... 省略 ... -->
</form>

如此一來,我們可以把 input 按鈕藏到畫面看不到的地方,然後輕易套用任何 style 到 label 上頭,做出井字空格。

input[type="radio"] {
  position: absolute;
  top: -9999em; /* 藏到畫面外 */
}

Hover 出後出現額外元素很常見,但怎麼讓他交替出現不同元素?

好,知道怎麼透過 input[type=radio] 來更改空格狀態之後,我們來研究作者是如何透過 Hover 後的不同樣式,呈現出 X 玩家與 O 玩家輪流的感覺呢?

要在游標 hover 時出現元素,就是結合僞類別與僞元素來在 Hover 到的空格上加入 XO。:

.tile label[for$='-o']:hover::before {
  content: "O";
}
.tile label[for$='-x']:hover::before {
  content: "X";
}

到這邊為止都不稀奇,不過還無法理解為何可以交替出現。

解答在原始碼中這段看起來很可怕的 css:

label[for$='-x'] {
  z-index: 1;
}

input:checked ~ #board label[for$='-o'] {
  z-index: 2;
}

input:checked ~ input:checked ~ #board label[for$='-x'] {
  z-index: 3;

}

input:checked ~ input:checked ~ input:checked ~ #board label[for$='-o'] {
  z-index: 4;
}
/* ... 省略一段 z-index 5 ~ 8 ... */

input:checked ~ input:checked ~ input:checked ~ input:checked ~ input:checked ~ input:checked ~ input:checked ~ input:checked ~ #board label[for$='-x'] {
  z-index: 9;

前面我們有提到,作者是利用 label 來做出井字空格以及 hover 後呈現的 XO 符號,而在 html 中可以看到每個空格底下都有這樣的結構:

<div class="tile" id="tile-0">
  <label for="cell-0-x"></label>
  <label for="cell-0-o"></label>
  <div></div>
</div>

搭配上方的 CSS,我們就能知道作者是透過更改 label 的 z-index交替地觸發 .tile label[for$='-o']:hover::before.tile label[for$='-x']:hover::before,進而達到想要的效果。

至於控制的方式就是透過 :check 為 true 的 input 數量:

當沒有任何一個 radio input 被 check 時,設定所有 for attribute 結尾為 -xlabelz-index 為 1,就會讓游標 hover 到空格時,會是 <label for="cell-0-x"></label> 的 hover 被觸發,而不是 <label for="cell-0-o"></label>

若有一個 radio input 被選擇後,代表要換成 O 玩家,input:checked ~ #board label[for$='-o'] 就被觸發了,所有 for attribute 結尾為 -olabelz-index 變為 2,大於剛剛的 x label,這時使用者在 hover 到任何一個空格時,出現的就通通都會是 O 了!

雖然這樣的做法讓 CSS 蠻冗長的,但還是不得不讚嘆作者能想出利用 z-index 來製造出這樣的效果,真的很厲害啊...閱讀性也算很高的。

判斷輸贏以及重新遊玩的功能怎麼實作?

在遊戲結束的時候,不管是哪種結果,畫面上都會疊上一層訊息與重新遊玩的按鈕,這部分的 html 藏在最下方含有 end class name 的 div 內:

<div id="end">
  <div id="message" class="center">
    <div>
      <input type="reset" for="tictactoe" value="Play again" />
    </div>
  </div>
</div>

由於整個遊戲都是包含在一個 form 中,所以可以直接透過 type 為 reset 的 input 按鈕來重設所有的 radio button 狀態,達到重新遊玩的功能。

而顯示訊息框以及判斷輸贏的方法其實蠻暴力的,就是一一檢查各種組合,橫的、直的與斜的:

#cell-0-x:checked ~ #cell-1-x:checked ~ #cell-2-x:checked ~ #end #message::before,
#cell-3-x:checked ~ #cell-4-x:checked ~ #cell-5-x:checked ~ #end #message::before,
#cell-6-x:checked ~ #cell-7-x:checked ~ #cell-8-x:checked ~ #end #message::before,
#cell-0-x:checked ~ #cell-3-x:checked ~ #cell-6-x:checked ~ #end #message::before,
#cell-1-x:checked ~ #cell-4-x:checked ~ #cell-7-x:checked ~ #end #message::before,
#cell-2-x:checked ~ #cell-5-x:checked ~ #cell-8-x:checked ~ #end #message::before,
#cell-0-x:checked ~ #cell-4-x:checked ~ #cell-8-x:checked ~ #end #message::before,
#cell-2-x:checked ~ #cell-4-x:checked ~ #cell-6-x:checked ~ #end #message::before {
  content: "Player 1 won!";
}

#cell-0-o:checked ~ #cell-1-o:checked ~ #cell-2-o:checked ~ #end #message::before,
#cell-3-o:checked ~ #cell-4-o:checked ~ #cell-5-o:checked ~ #end #message::before,
#cell-6-o:checked ~ #cell-7-o:checked ~ #cell-8-o:checked ~ #end #message::before,
#cell-0-o:checked ~ #cell-3-o:checked ~ #cell-6-o:checked ~ #end #message::before,
#cell-1-o:checked ~ #cell-4-o:checked ~ #cell-7-o:checked ~ #end #message::before,
#cell-2-o:checked ~ #cell-5-o:checked ~ #cell-8-o:checked ~ #end #message::before,
#cell-0-o:checked ~ #cell-4-o:checked ~ #cell-8-o:checked ~ #end #message::before,
#cell-2-o:checked ~ #cell-4-o:checked ~ #cell-6-o:checked ~ #end #message::before {
  content: "Player 2 won!";
}

並將含有 end class 的 div 設為 display: block,當然,也是要確定所有 checked 狀態都是正確的(已經結束):

input:checked ~ input:checked ~ input:checked ~ input:checked ~ input:checked ~ input:checked ~ input:checked ~ input:checked ~ input:checked ~ #end,
#cell-0-x:checked ~ #cell-1-x:checked ~ #cell-2-x:checked ~ #end,
#cell-3-x:checked ~ #cell-4-x:checked ~ #cell-5-x:checked ~ #end,
#cell-6-x:checked ~ #cell-7-x:checked ~ #cell-8-x:checked ~ #end,
#cell-0-x:checked ~ #cell-3-x:checked ~ #cell-6-x:checked ~ #end,
#cell-1-x:checked ~ #cell-4-x:checked ~ #cell-7-x:checked ~ #end,
#cell-2-x:checked ~ #cell-5-x:checked ~ #cell-8-x:checked ~ #end,
#cell-0-x:checked ~ #cell-4-x:checked ~ #cell-8-x:checked ~ #end,
#cell-2-x:checked ~ #cell-4-x:checked ~ #cell-6-x:checked ~ #end,
#cell-0-o:checked ~ #cell-1-o:checked ~ #cell-2-o:checked ~ #end,
#cell-3-o:checked ~ #cell-4-o:checked ~ #cell-5-o:checked ~ #end,
#cell-6-o:checked ~ #cell-7-o:checked ~ #cell-8-o:checked ~ #end,
#cell-0-o:checked ~ #cell-3-o:checked ~ #cell-6-o:checked ~ #end,
#cell-1-o:checked ~ #cell-4-o:checked ~ #cell-7-o:checked ~ #end,
#cell-2-o:checked ~ #cell-5-o:checked ~ #cell-8-o:checked ~ #end,
#cell-0-o:checked ~ #cell-4-o:checked ~ #cell-8-o:checked ~ #end,
#cell-2-o:checked ~ #cell-4-o:checked ~ #cell-6-o:checked ~ #end {
  display: block;
}

主要的實作重點大概就到這邊,剩下還有一些像是 radio button 被 check 後,把原有 label 設為 display: none 的部分就是為了讓畫面更好看而已,實作方式跟上面的 selector 都大同小異。

實作技巧整理

最後稍微統整一下實作的技巧:

  • 實作技巧 1 - 善用 form 元素: input[type=radio]label

    利用 radio button 來達成切換狀態的操作,在各種以純 CSS 製作的應用中,幾乎是必備出現的技巧,這邊也不例外。

    透過 input 元素,我們可以從 :check 這個 Pseudo-class 來判斷使用者的點擊與否。

  • 實作技巧 2 - CSS selector 可不是只有 id 與 class

    活用 attribute selector 與 僞元素、僞類別,可以帶來很多意想不到的妙用,像是 label[for$='-o']:check 等等。

  • 實作技巧 3 - 藏東西不是只能用 display,還有 z-index 呢

    利用 z-index 的階層關係,控制觸發 hover 的元素,讓我們多了一種隱藏物件的方式。

  • 實作技巧 4 - CSS Grid

    雖然在前面並沒有提到,但作者是利用 CSS Grid 畫出表格,這在 Modern web browser 上是最經濟實惠的方法:

    <div id="board" class="center">
        <div class="tile" id="tile-0">
          <div></div>
        </div>
        <div class="tile" id="tile-1">
          <div></div>
        </div>
        <!-- 
          ...  依此類推將九個格子填滿
        -->
      </div>
    
    /*
    ... 省略
    */
    #board {
      width: 50vmin;
      height: 50vmin;
      display: grid;
      grid-template-columns: 1fr 1fr 1fr;
      grid-template-rows: 1fr 1fr 1fr;
    }
    
    #tile-0 {
      grid-column: 1;
      grid-row: 1;
    }
    
    #tile-1 {
      grid-column: 2;
      grid-row: 1;
    }
    /*
    ... 省略
    */
    

結論

每每看到 codepen 上一些神奇的作品,都會讓我有 mind-blowing 的感覺,雖然大多時候難以理解實作原理,但偶而還是會看到類似這次範例一般,好理解,又能學到不少技巧的作品。
希望對 CSS 不那麼熟悉的讀者,透過這次的分析,也能看得懂背後的原理,然後對利用 CSS 繪圖或是製作 no-js 的作品有所興趣,雖然沒什麼實際用途,但我自己覺得能夠利用專業玩出一些好玩的東西真的蠻吸引人的!

資料來源

  1. CSS Tic-Tac-Toe - Alvaro Montoro

關於作者:
@arvinh 前端攻城獅,熱愛數據分析和資訊視覺化


#css









Related Posts

Python 程式設計函式的參數和回傳值入門教學

Python 程式設計函式的參數和回傳值入門教學

Node.js Advanced Interview Questions for Experienced Professionals

Node.js Advanced Interview Questions for Experienced Professionals

一看就懂的 React Server Rendering(Isomorphic JavaScript)入門教學

一看就懂的 React Server Rendering(Isomorphic JavaScript)入門教學




Newsletter




Comments